今天來聊聊「多線程」的單元測試。
當系統成長到一個程度,效能的重要性就會慢慢浮現,隨著使用者數量越來越大,「效能」的影響也會變大,最終變成系統的瓶頸。如果放任不管,用戶體驗就會變差,甚至有可能影響程式的正確性,造成巨大的損失。「多線程」(Multi-threaded)的設計,正是解決效能問題的方法之一。
舉例來說,在我們的「教務處網站」上,同學可以申請獎學金。而最終,無論結果如何,我們都得發個 Email 通知申請者對吧?然而我們知道,Email 的寄發牽扯到蠻大比重的 I/O 操作,不是一件很快的事,如果一封一封發,那執行時間就太久了。於是我們想用 Multi-threaded 通知的方式來解決這個問題。
問題來了,回頭看我們先前提到的單元測試三步驟:準備資料、執行、檢查結果,這三件事是有時序性的,我不等到執行完畢,我其實沒辦法測,因為結果還沒出來啊。
「我可以等。」請問,你要等多久?任務一旦丟給 Thread 去跑,後面的事情就是機器在做,等於是離開我們掌控範圍了,你怎麼知道要等到什麼時候?
「那就等久一點啊。」久一點是多久?3 秒夠不夠?3 分鐘夠嗎?首先,如上所述,我已經把任務丟給機器去做了,機器根本就沒有跟我保證他什麼時候要排到我的工作。就算你只等 3 秒吧,問題這本來是個 30 msec 能做完的事,硬被你拖到 3 秒,這事兒一旦發生多了,你的測試就會跑很久。
「跑很久有什麼問題?」Kent Beck 説過:「Programmer tests should be fast.」,程式開發者寫的測試必須要快,跑得慢的測試,一來打斷思緒,二來大家嫌麻煩就不想測了。那你寫了一個測試結果沒人要跑,不是很浪費時間嗎?所以,「固定時間的等待」不是一個很好的測試方法。
「那就不要測好了,請 QA 幫我們測?」你,站起來,出去!XD
截圖自 Youtube
言歸正傳,測還是得測的。
坊間對此問題有蠻多解法,有些還是蠻直覺的,以下介紹兩個筆者自己實務上比較常用的方法。
有些方法是會回傳值的。這種會稍微比較好測一點。我們可以拿個 Future 去接。譬如舉個最簡單例子,如果發 Email 後會回傳 boolean,告訴我成功或失敗,這時呼叫者可以利用 Future 的接口,來查看任務的回傳值。這個呼叫者當然也可以是個 Unit Test 的測項,這時我們就可以在測項裡驗證結果了。
我們先來看看這樣的程式該怎麼寫:
public class SendResultEmailService {
private final Mailer mailer;
private final ExecutorService executorService = Executors.newFixedThreadPool(300);
// ... 中略
public List<Future<Boolean>> send(List<ScholarshipResult> results) {
List<Future<Boolean>> futures = new ArrayList<>();
for (ScholarshipResult result : results) {
futures.add(executorService.submit(() -> mailer.send(result)));
}
return futures;
}
}
因為方法會回 Future,所以我們大可以在測試裡把 Future 打開來看回傳值是否符合預期,如下:
@Test
void when_send_returns_future() throws ExecutionException, InterruptedException {
// 準備假 Mailer
Mailer mailer = Mockito.mock(Mailer.class);
SendResultEmailService service = new SendResultEmailService(mailer);
// 假 Mailer 會回傳兩個 true,一個 false
when(mailer.send(any(ScholarshipResult.class)))
.thenReturn(true, true, false);
// 跑起來
List<Future<Boolean>> futures = service.send(
Arrays.asList(
new ScholarshipResult(),
new ScholarshipResult(),
new ScholarshipResult()
));
// 檢查 Future 裡 true 與 false 的個數
int goods = 0;
int bads = 0;
for (Future<Boolean> future : futures) {
if (future.get()) {
goods++;
} else {
bads++;
}
}
assertEquals(2, goods);
assertEquals(1, bads);
}
我們利用了 Future 的 get 介面,強迫測試等所有任務都執行完再來檢查結果。但這裡的「等待」,跟先前說的等待不同,如果你的等待,是等一個固定秒數,那就會遇到等待時間很難抓,或是等太久浪費時間的問題。這裡則是等任務完成後自動往下走,所以時間不會浪費。
By the way,各位有沒有發現,上述的測試如果不看註解,還是得花一番工服才能理解?其實我也這麼認為。這種測試正確有餘,表現力卻不足,如果能重構一下就更好了:
@Test
void when_send_returns_future_refactor_the_test() throws ExecutionException, InterruptedException {
assume_mailer_execution_result_would_be(true, true, false);
when_send_with_results(3);
then_counts_in_futures_will_be(true, 2);
then_counts_in_futures_will_be(false, 1);
}
這裡用了一些手法,刻意地將一些實踐細節隱藏起來,好讓測項的第一層只剩下一些「商務邏輯」的敘述。至於藏起來的細節哪兒去了?GitHub Repository 中有詳細的程式碼,讀者可以抓下來參考一下。如果覺得不需要知道這麼細,那其實看上面的程式碼也就夠了。這也是我們重構的目的。
也許你會好奇:「不是測完就好了?幹嘛要重構?」要知道,測試也是程式,也會有壞味道的。而「高效程序員的 45 個習慣:敏捷開發修煉之道」一書中,作者告訴我們,重構的最佳時機,就是測試通過的時候。這時你的腦中,對剛剛寫的東西印象還很深刻,這時重構效果最好。等你過三天五天再回頭看這段程式,看都看不懂,還重構什麼?
所以,測試通過了,就先考慮重構,程式測試都要。
那麼,總有任務是不回傳值的吧?那又該怎麼測?
這個問題的確普遍存在,像前一篇有講到,如果我們設計程式時把 Command 與 Query 分開,那我們就沒有辦法如法泡製,透過 Future 檢驗回傳值了。這時該怎麼辦呢?
其實,山不轉路轉,我們還是可以測行為。
一個多線程的功能,都會由兩個行為組成,一個是「任務本身的行為」,一個是「將任務排程的行為」(以 Java 來說,就是 ExecuteService 的 submit 行為)。這時,我們可以將兩件事情分別測試,先視你的商務邏輯,單獨驗測任務本身的行為,再將任務做成假物件,單獨驗測「排程」的行為有沒有如預期發生。
至於要怎麼「驗行為」,這個我們上篇聊過了,其實也就依樣畫葫蘆而已。像這樣間接驗測,其實是建立於我們對依賴本身的信任。我們認為任務的行為本身是檢驗過的、正確的,那其實只要確保我們有依需求發送任務就好。
當然遇到多線程的場景,還是有其他方法可以驗,本篇只是介紹筆者自己較常用的兩種方法。讀者也許已經發現了,多線程的程式要好測,你設計的結構還是得配合才行,譬如「任務」與「排程」如果混在一起實現,測起來就會比較麻煩一點。但這點其實不管是不是多線程都一樣,只是多線程的場景會讓不良結構的易測性降低得更明顯而已。
稍早提到的「高效程序員的 45 個習慣」,以及另一本「The Pragmatic Programmer」,兩本書中都有提到另一個好的習慣,就是「讓測試當你程式的第一個使用者」。這是有原因的,如同我們一再強調的,好測,就會好用。你先測看看,你才知道到時候人家用你的功能會不會好用。
謎之聲:「『高效程序員的 45 個習慣』是本好書,值得一讀!」
ithelp2021